Skip to content

4. RAG 检索增强生成特训指南

本指南结合您简历中 "sky-ai RAG 检索器实现 FAQ 知识库问答""PgvectorVectorStore 向量数据库 Bean 自动注入""RagAdvisor 检索上下文注入""离线文档处理与分块策略" 场景进行深度定制,全面采用 Why - What - How - Deep 极简源码/原理速成结构。


一、 RAG 全链路核心架构

Why(幻觉治理与开卷考试的必要性)

  • 幻觉与时效黑洞:大模型的参数知识在预训练完成后就完全锁定,对于企业私有的内部 FAQ、实时订单数据、或是最新行业规范完全无知。若直接提问,模型会基于自回归采样编造虚假信息。
  • 高昂的微调成本:通过 Full Fine-Tuning(全参数微调)注入私有知识,需要昂贵的算力和极高的数据集清洗开销,且一旦知识更新(如菜品价格变动),就必须重新微调,时效性极差。
  • RAG 的降维打击:RAG 将问题转化为“检索事实”,在生成前将事实以“开卷考试”形式喂给大模型,模型仅负责阅读理解与润色,从根本上攻克了幻觉和时效性缺陷。

What(离线索引与在线生成双管道)

  • RAG 架构物理上拆分为两条独立并行的管道:
                        【离线索引 Pipeline】
    原始 FAQ 原始文件 ──> Parse (解析清洗) ──> Chunk (切分分块)
    
    
    向量库 (Pgvector) <── Vector Store <── Embedding (高维向量化)
    
                        【在线生成 Pipeline】
    用户提问 ──> Query 重写 ──> 向量相似度检索 ──> Cross-Encoder 重排 (Rerank)
    
    
    最终回复 <── 大模型阅读生成 <── 织入 SystemMessage (RagAdvisor) <── Top-N 截断

How(sky-ai 中的检索注入流程)

  • sky-ai 在在线管道中,使用 RagAdvisor(优先级设为 HIGHEST_PRECEDENCE + 3)在工具链过滤前强插拦截。
  • RagAdvisor 从用户请求中提取提问,调用 OnlineRetrievalService 触发相似度搜索与重排,获取精选候选块。
  • 将召回文本打包,以 "使用以下检索上下文回答问题:\n" 前缀拼入 SystemMessage 强插在 Prompt 首位,最后 mutate 组装成不可变的 Request 向后传递。
  • 对线重点👉 跳至:sky-ai RAG 管道大厂对线

Deep(三方 Rerank API 线上超时崩溃 NullPointerException Bug 治理实战)

  • 线上事故成因:早期版本中,我们为了提升重排精度,自研了 SiliconFlowRerankerClient 对接三方 SiliconFlow Rerank 服务。然而,在面对高并发大流量时,三方 API 时常因为触发 429 限流或网络抖动响应超时。
  • NPE 物理崩溃:我们在解析 JSON 响应时,由于把保底的重排分数值(Score)错写为了局部变量的自我赋值,导致当网络抛出异常时,返回给重排服务的是一个未被正确赋值分数的 Null 实体。当下游执行 chunk.score() 时,直接触发 NullPointerException 崩毁整个 AI 客服请求链路。
  • 自愈熔断降级重构:我们在 RerankingService 中设计了完备的自愈拦截。一旦捕获到三方 Rerank API 超时或连接异常,立即激活 Fallback 降级开关,就地放弃三方重排,直接回退并退化到向量库检索时自带的相似度打分(embeddingScore)进行 Top-N 重新排序与截断。该自愈设计在完全不阻断核心业务响应的前提下,达成了 99.99% 的高可用性。
  • 对线重点👉 跳至:重排异常降级自愈大厂对线

二、 向量数据库选型与 HNSW / IVFFlat 索引底层

Why(高维向量检索 $\mathcal{O}(N)$ 的计算诅咒)

  • 线性搜索计算灾难:要在一亿条向量知识库中寻找与当前用户 Query 相似度最高的一条,如果采用暴力搜索(Brute Force),必须用 Query 向量去逐一计算余弦距离,计算复杂度为 $\mathcal{O}(N \times D)$($N$ 是向量个数,$D$ 是维度,如 1536 维)。在高并发下,这会导致 CPU 瞬间被打满,查询延迟达到秒级。
  • 近似最近邻(ANN)加速:为了打破这个屏障,向量数据库引入空间分区索引算法,将线性搜索降维,以牺牲极其微弱的精度为代价换取对数级 $\mathcal{O}(\log N)$ 的超快响应。

What(Pgvector 选型考量与工业界对比)

  • 向量库选型物理格局
    数据库物理形态分布式架构支持索引适合数据量级运维特征
    PgvectorPG 原生扩展依托 PG 本身HNSW, IVFFlat100万级以内极简,无需引入额外中间件,事务一致
    Milvus独立专用向量库微服务多节点分布式HNSW, IVF, ScaNN亿级以上极其臃肿,存在 ZooKeeper/MinIO 运维开销
    QdrantRust 专用向量库分布式高可用HNSW千万级性能极高,内存占用极大
  • Pgvector 的绝对性价比:在 sky-ai 项目中,业务数据本就存储于 PostgreSQL。通过开启 pgvector 扩展,业务字段(如 dish_id)可以与向量字段(embedding)存放在同一张表里。单条 SQL 语句即可无缝支持混合过滤(如:先 where status = 1 过滤营业菜品,再计算向量距离),且天然支持 ACID 事务,完美解决向量数据与业务数据的强一致性冲突。
  • 对线重点👉 跳至:向量数据库工程选型大厂对线

How(Spring AI 自动装配原理)

  • pom.xml 中引入 spring-ai-pgvector-store-spring-boot-starter
  • 底层原理利用 @ConditionalOnClass(PgvectorVectorStore.class) 条件注解,一旦类路径存在该类,且 YAML 中配置了正确的 spring.datasource,Spring Boot 就会自动实例化并向 Spring 容器注入 PgvectorVectorStore 的全局 Singleton Bean,实现就地免代码配置装配。

Deep(HNSW 与 IVFFlat 底层索引数学机理)

  • HNSW (Hierarchical Navigable Small World, 分层导航小世界)
    【HNSW 导航跳跃搜索机理】
    Layer 2 (稀疏粗层) ─────●─────────────────────────────● (大步跳跃寻优)
                           │                             │
    Layer 1 (过渡层)   ─────●─────────────●───────────────●
                           │             │               │
    Layer 0 (密集底层) ─────●──────●──────●──────●──────●──────● (精确滑行锁定)
    • 数学机理:借鉴了跳表(SkipList)的分层设计理念,构建一个多层图结构。最高层图结构极其稀疏,只保留极少数的采样向量;越往下层,节点和连接线越稠密,底层(Layer 0)包含全部物理向量。
    • 跳跃寻优:检索时,从最高层的入口节点开始,进行局部贪婪梯度搜索。当在当前层找不到更近的邻居时,通过层间通路跳跃下降到下一层继续搜寻,在底层完成精准锁定。这种设计规避了全局遍历,检索时间复杂度暴降至对数级 $\mathcal{O}(\log N)$。
    • 核心控制参数
      • M(每个节点的最大邻居数,范围 4-64):设定值越大,高维空间关联密度越高,召回精度越强,但显存占用与建索引时间成平方级上升。
      • efConstruction(构建图结构时的搜索候选集深度):值越大,建图越慢,但能产出完美的图拓扑结构。
      • efSearch(查询时的动态扩展边界):查询时动态保留的最邻近候选集大小。设定 efSearch 偏大可以有效抑制“图断裂”导致的漏召回,但会增加耗时。
  • IVFFlat (Inverted File Flat, 倒排文件扁平量化)
    • 数学机理:基于传统的 K-Means 均值聚类。第一步,在构建索引时,通过聚类算法将全体高维向量划分并聚类为 $N$ 个虚拟的群簇中心(即参数 nlist)。
    • 分组倒排:将每一个物理向量的 ID 挂载到其最接近的聚类中心的倒排表下。
    • 探针查询:检索时,首先计算 Query 向量与所有聚类中心的余弦距离,仅挑出最接近的几个聚类中心(参数 nprobe,即探针深度),然后只在这些中心的倒排表内进行精确相似度暴力计算,将其余绝大多数无关类簇直接过滤,节省 90% 以上的计算开销。

三、 Hybrid Search(混合检索)与 RRF 融合打分

Why(纯向量检索与关键词检索的互补自救)

  • 纯语义向量检索的盲区:Embedding 向量极其擅长“模糊语义匹配”,但在完全精确匹配场景表现极其差劲。例如,当用户提问:“帮我查下我的订单号是 sky-10023908 的进度”,Embedding 模型无法从“sky-10023908”这个无规律的代码哈希中提取出合理的语义向量,检索时极易漏召回正确的订单文档块。
  • 关键词检索的盲区:传统的倒排索引关键词匹配(如 Elasticsearch 的 BM25 算法)对“同义词、语境、概念”完全瞎眼。如果用户提问:“这家店是否有过敏保护?”,但 FAQ 中记录的是“本店配备饮食禁忌防御机制”,因为关键词不重合,BM25 会完全无法检索。
  • 混合检索的物理自救:必须将两路检索(语义向量 + 精准关键词)并联运行,取长补短,才能支撑生产级的业务诉求。

What(双路检索物理特征对比)

  • 密集向量 (Dense Embedding) 检索:用浮点型密集矩阵表示,捕捉整段话的高维抽象语义(表达“想吃甜食”)。
  • 稀疏向量 (Sparse Vector / BM25) 检索:捕捉极高频的特定字符匹配,进行高置信度物理精确打击(表达“sky-10023908”)。

How(RRF 合并打分去重服务)

  • sky-ai 通过 RetrievalFusionService 实现了高性能的 RRF (Reciprocal Rank Fusion, 倒数排名融合) 算法:
    1. 并行拉取向量语义检索与 BM25 关键词检索候选集。
    2. 利用多级哈希算法防重清洗:首先比对 chunkHash,再比对元数据 chunkId,最后比对内容 MD5 值。
    3. 利用经典 RRF 倒数公式累加打分,并进行排序和 Top-N 过滤截断。
  • 对线重点👉 跳至:混合检索去重融合与 RRF 算法对线

Deep(RRF 公式推导与 uniqueKey 去重设计)

  • RRF 倒数排名融合数学公式: $$RRF_Score(d \in D) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$
    • $M$ 代表检索管道的数量(这里是 2,即向量路和 BM25 路)。
    • $r_m(d)$ 是文档 $d$ 在第 $m$ 个检索管道中被召回时的物理排名字次(例如排在第 1 位的 $rank = 1$,排在第 10 位的 $rank = 10$)。
    • $k$(通常默认设为 60)是平滑因子(Hyperparameter)。引入 $k$ 的目的是防止排在第一二名的高名次文档获得过大的爆炸级分值,平衡低排名文档的合理贡献,拉平整体的归一化曲线。
  • uniqueKey 多级去重的防污染哲学
    • 在高频检索或多文档重叠切分下,同一个 FAQ 文档块可能会因为不同的匹配路径(比如带空格和不带空格)被向量路和关键词路同时召回。
    • 若直接无脑丢给 LLM,会在上下文中产生严重的信息冗余污染(Prompt Padding),挤占有限的 Context 窗口。
    • 处理机制:我们在 uniqueKey() 方法中,建立了严密的物理去重键生成链: $$\text{UniqueKey} = \text{Coalesce}(\text{Metadata.chunkHash}, \text{Metadata.chunkId}, \text{Hash(Chunk.content)})$$ 在向合并 Map 中添加元素时,利用该唯一键做就地合并。一旦发生 key 碰撞,就将两路检索的 RRF 贡献分叠加到同一个 Accumulator 中,既完成了物理去重,又合理体现了该文档块“在两路检索中名次均靠前”的极高相关度。

四、 高阶检索优化与知识库热更新策略

Why(数据生命周期维护与语义对齐)

  • 静态知识库的老化死区:随着业务推进,菜品下线、价格调整、配送范围变化频繁发生。如果不做热更新,RAG 检索出的过期信息会直接误导模型,造成严重的交易合规风险。
  • 自回归检索的结构不匹配:用户的 Query 通常是疑问句(“我怎么退款?”),而知识库中存的 Chunk 是陈述句(“退款流程为...”)。两者的特征空间向量存在物理夹角,导致检索召回率天然受阻。

What(重排、改写与增量热更新)

  • 重排序 (Rerank)
    • 在第一路相似度召回(如 HNSW)时,使用的是 Bi-Encoder (双塔架构)。 Query 和 Document 相互独立进行 Embedding 计算,丢失了交叉 Attention 信息。
    • Rerank 采用 Cross-Encoder 架构。将 Query 和 Candidate Document 拼成一条长文本一次性输入大模型/重排模型,让它们在自注意力机制中发生深度的“全交互式两两 Attention 计算”,输出一个极高精度的语义相关性 Score,完成精准二次排序。
  • 知识库更新策略
    • 增量更新 (MD5 碰撞):计算新增文件的 MD5 摘要与向量库存储的 MD5 元数据比对,若未发生变化直接跳过;若变化,则物理删除旧的 Chunk 向量并重构入库。
    • 蓝绿部署全量重建:当更替底层 Embedding 模型(如从 768 维升级为 1024 维)时,必须进行全量数据重建。此时新建一个 shadow vector index 并在后台灌满数据,灌满后原子切换读写指针(Alias),实现 0-Downtime 无感知部署。

How(Reranking 流程编排)

Deep(Cross-Encoder vs Bi-Encoder 性能/精度博弈与 GraphRAG 机制)

  • 计算开销与精度对比
    【Bi-Encoder 双塔检索 (速度极快,精度中等)】
    Query ──> [Embedding] ──┐
                            ├──> [余弦相似度计算 (O(D) 极低开销)] ──> 候选集
    Doc   ──> [Embedding] ──┘
    
    【Cross-Encoder 单塔重排 (速度较慢,精度极高)】
    [ Query + Doc ] ──> [ 深度 Transformer 交互 Attention ] ──> 精准 Score
    • Bi-Encoder:可以将所有 Doc 的向量提前预计算存入 Pgvector,在线仅需计算一次 Query Embedding 加上毫秒级的向量距离,适合第一阶段召回大规模候选集。
    • Cross-Encoder:由于不能提前预计算,每次请求必须对 Top-50 的每一个候选 Doc 都进行一次深度的 Transformer 前向推理,计算开销极其恐怖。如果在第一阶段直接用 Cross-Encoder 对百万文档直接打分,检索耗时将达数分钟。
  • GraphRAG 构建精髓(Leiden 算法聚类)
    • 传统 RAG 的致命短板是无法回答全局概括性问题(如:“帮我总结下这个月所有的客诉痛点有哪几类?”)。因为这些信息零散分布在成百上千个不同的 Chunks 中,Bi-Encoder 无法一次性把它们全部召回。
    • GraphRAG 破局:第一步,利用大模型提取文档实体与关系,构建全局知识图谱。
    • 第二步,利用 Leiden 社区发现算法,从拓扑结构上对关联紧密的实体网进行聚类,划分出多层级的“社区(Communities)”。
    • 第三步,使用大模型为每个社区生成独立的“概括性摘要(Summary)”并存储。
    • 全局搜索(Global Search)机制:当遭遇全局概括性提问时,直接在“社区摘要”这一层级进行 Map-Reduce 式的高层检索,完美解决了传统 RAG 面对宏观概括问题时的彻底漏招回和断裂痛点。

五、 简历亮点与大厂面试对线(袁志刚专属)

1. sky-ai RAG 管道与【RAG 全链路工程】

  • 面试官切入点

    "你的 AI 客服项目中集成了 RAG 检索增强,请完整描述一下你的 RAG 链路——从文档入库到检索到生成的全过程。"

  • 袁志刚专属特训回答模版
    1. 离线索引管道 (Offline Pipeline):我们将原始知识库(支持 PDF/Markdown/FAQ 结构化文本)打入解析层,采用滑动窗口递归切分策略(Chunk 大小定为 500 Token,前置重叠 50 Token),避免关键信息因边缘切分而丢失。分块后的 Chunks 调用 Embedding 模型向量化,最后存入 PgvectorVectorStore 向量存储中,其中我们利用 Spring AI 的 Auto-Configuration 机制实现 IoC 自动装载。
    2. 在线检索管道 (Online Pipeline):当用户发出提问,请求被 AI 核心过滤链拦截,触发 RagAdvisor。检索器首先发起混合检索(Hybrid Search),在向量路和关键词路召回候选集后执行 RRF 打分融合和去重。去重后的前 50 条文本送入 Cross-Encoder 重排服务精筛,截取最相关的 Top-5 知识块。
    3. 上下文动态注入防御失忆RagAdvisor 将这 Top-5 重排知识块进行安全和非空校验,格式化为 "使用以下检索上下文回答问题:\n" + retrievalResult.context(),通过 SystemMessage 强行插在整个 Message 数组的第 0 位(最顶部),随后调用 chatClientRequest.mutate() 替换 Prompt 整体向下游传递。这样既保证了大模型拥有绝对的业务背景知识,又利用位置首尾效应,杜绝了模型长文本失忆与幻觉的可能。

2. PgvectorVectorStore 与【向量数据库工程选型】

  • 面试官切入点

    "你为什么选择 Pgvector 而不是 Milvus、Pinecone 或 Qdrant?Pgvector 的优势和局限性是什么?"

  • 袁志刚专属特训回答模版
    1. 中小型知识库下的性价比与运维决策:我们立项时核心评估了知识库文档规模(目前系统内 FAQ 和业务文档总量在 10 万条以内,对应向量 Chunk 数在 100 万左右)。在这种量级下,如果强行引入 Milvus 等独立专用向量数据库,不仅需要额外部署 Zookeeper、MinIO 以及专用的物理索引集群,还会带来极高的网络往返耗时和运维成本。
    2. 混合过滤与事务一致性(核心痛点):在 AI 客服场景下,检索必须带有业务过滤条件(例如:仅检索当前商铺 shop_id = 123 且处于上架状态 status = 1 的菜品知识)。如果使用独立向量数据库,必须先在 Milvus 里根据向量相似度召回 Top-N,再在 Java 服务端根据 SQL 进行业务过滤,极易导致“业务过滤后候选集所剩无几”的预过滤断裂问题。而 Pgvector 可以在一张表内同时存储业务字段和向量字段,利用 PostgreSQL 强大的查询规划器,在一条 SQL 语句内完成混合预过滤(Pre-Filtering)与 HNSW 索引检索,极大地提升了系统的吞吐量与开发效率。同时,它支持 ACID 事务,业务数据更新时,向量索引同步写入,保证了 100% 的数据一致性。
    3. 架构局限与高阶规划:当然,Pgvector 在大规模分布式横向扩展(如千万级/亿级向量)时的查询延迟和并发支撑确实不如 Milvus,且对内存吃得极狠。对于我们目前的量级,Pgvector 配合 HNSW 索引是极致性价比、稳定性最高的最优解。

3. 混合检索去重融合与 RRF 算法对线

  • 面试官切入点

    "你在混合检索中使用了 RRF 算法,能详细说说为什么要用它,以及你们在 Java 端是如何具体去重并融合打分的吗?"

  • 袁志刚专属特训回答模版
    1. 双路互补的工程必要性:纯语义向量检索擅长处理口语化同义词(如“有没有不放海鲜的饭”与“避开海鲜禁忌”匹配),但在精确的特定代码(如查订单号“order_001”或菜名“鱼香肉丝”)场景漏招回率极高;而传统基于 TF-IDF 演进的 BM25 关键词检索正好相反。我们将两路并联检索,获取各自召回的文档候选集。
    2. 去重融合的实现逻辑:我们使用 RetrievalFusionService 开展融合。因为同一文档分块常在两路同时被召回,直接拼入 Prompt 会引发信息冗余。我们以 uniqueKey() 方法为基座,按 Metadata.chunkHash -> Metadata.chunkId -> Hash(Content) 的次序降级生成唯一哈希。
    3. RRF 倒数公式的应用:在遍历两路文档时,一旦发生 Key 碰撞,我们就使用公式: $$Score = \sum \frac{1}{60 + \text{Rank}}$$ 将该 Chunks 在两路各自的名次 Rank 代入求倒数后累加,最终重新降序排序。这样,如果一个 Chunks 在语义向量路排第一,BM25 关键词路也排第二,它的分值将极为突出;而仅在某一路靠后排名的 Chunks 分数会被迅速衰减。这套算法彻底保障了召回上下文的高相关度与干净度。

🖥️ 核心支撑源码:混合检索去重融合与 RRF 算法实现

java
// RetrievalFusionService.java - RRF 混合打分去重服务
@Service
@RequiredArgsConstructor
public class RetrievalFusionService {
    private final OnlineRetrievalProperties properties;

    public List<RetrievedChunk> fuse(List<RetrievedChunk> embeddingCandidates, List<RetrievedChunk> keywordCandidates) {
        // 核心:基于 uniqueKey 去重,并累计两路检索的 RRF 贡献分数
        Map<String, Accumulator> merged = new LinkedHashMap<>();
        addRankedCandidates(merged, embeddingCandidates);
        addRankedCandidates(merged, keywordCandidates);

        int limit = Math.max(1, properties.getFusion().getMaxCandidates());
        return merged.values().stream()
                .map(Accumulator::toChunk)
                .sorted(Comparator
                        // 优先按 RRF 融合分(fusionScore)降序,相同时按向量分(embeddingScore)降序
                        .comparingDouble((RetrievedChunk chunk) -> metadataDouble(chunk, "fusionScore")).reversed()
                        .thenComparing(Comparator.comparingDouble(RetrievedChunk::embeddingScore).reversed()))
                .limit(limit)
                .toList();
    }

    private void addRankedCandidates(Map<String, Accumulator> merged, List<RetrievedChunk> candidates) {
        for (int i = 0; i < candidates.size(); i++) {
            RetrievedChunk candidate = candidates.get(i);
            String key = uniqueKey(candidate);
            // 经典 RRF 打分公式:score = 1.0 / (K + rank)
            double contribution = 1.0d / (properties.getFusion().getRrfK() + i + 1.0d);
            merged.computeIfAbsent(key, ignored -> new Accumulator(candidate)).add(candidate, contribution);
        }
    }

    private String uniqueKey(RetrievedChunk chunk) {
        // 多级哈希校验去重:主键 -> 文档分块坐标 -> 内容哈希,防范内容重复污染 Prompt
        Object chunkHash = chunk.metadata().get("chunkHash");
        if (chunkHash != null && !chunkHash.toString().isBlank()) return "chunkHash:" + chunkHash;
        Object chunkId = chunk.metadata().get("chunkId");
        if (chunkId != null && !chunkId.toString().isBlank()) return "chunkId:" + chunkId;
        return "content:" + chunk.content();
    }
}

4. 在线检索编排与重排异常降级自愈对线

  • 面试官切入点

    "在高并发或者网络波动下,你们的在线重排序(Rerank)服务如果挂了,你们是怎么做故障自愈的?"

  • 袁志刚专属特训回答模版
    1. 三方依赖的天然脆弱性:在线重排是我们整个 RAG 检索精度的最后一公里,但因为其底层需要调用外部重排 API(如 SiliconFlow 提供的 Cohere-Rerank 接口),在生产环境的网络抖动、网络超时、或三方算力拥堵限流下,该接口有着极高比例的失败概率。如果直接放任崩溃,会导致用户的所有 AI 客服问答直接报错挂掉。
    2. 高可用重排器设计与降级拦截:我们在 RerankingService 中进行了严密的异常包装设计。核心的 rerank 管道在调用三方 Client 时套入 try-catch 保护。一旦遭遇任何超时、50x 或 429 限流异常,控制台立刻捕获警报,同时依据配置参数判断是否开启自愈 fallback。
    3. 无缝降级为 Embedding Score 排序:若 fallback 开启,我们会将原本传入的 RRF 去重召回候选集,直接按照它们在第一阶段被向量库检索出的 embeddingScore(相似度分数)进行倒序排序,直接截取前 Top-N 条返回给组装器,彻底跳过三方 API 重试阻塞。这套无缝退避降级方案仅使重排精度受到极其微弱的偏角影响,但挽救了整条在线生成链路,为 AI 系统带来了极致的抗网络动荡弹性。

🖥️ 核心支撑源码:在线检索编排与重排异常降级自愈

java
// OnlineRetrievalService.java - 在线检索流程编排
@Service
@RequiredArgsConstructor
public class OnlineRetrievalService {
    private final CandidateRetrievalService candidateRetrievalService;
    private final RerankingService rerankingService;
    private final ContextAssemblyService contextAssemblyService;

    public RetrievalResult retrieve(String query) {
        // Step 1: 召回候选文本块(包含 RRF 混合检索去重)
        List<RetrievedChunk> candidates = candidateRetrievalService.retrieveCandidates(query);
        // Step 2: 重排序(包含 SiliconFlow API 超时熔断自愈降级)
        List<RetrievedChunk> finalChunks = rerankingService.rerank(query, candidates);
        // Step 3: 上下文组装
        return contextAssemblyService.assemble(query, finalChunks);
    }
}

// RerankingService.java - 重排高可用兜底自愈服务
@Service
@RequiredArgsConstructor
public class RerankingService {
    private final RerankerClient rerankerClient;
    private final OnlineRetrievalProperties properties;

    public List<RetrievedChunk> rerank(String query, List<RetrievedChunk> candidates) {
        if (candidates.isEmpty()) return List.of();
        try {
            List<RetrievedChunk> reranked = rerankerClient.rerank(query, candidates);
            int limit = Math.min(properties.getTopN(), reranked.size());
            return reranked.subList(0, limit);
        } catch (Exception ex) {
            log.error("Reranker 重排 API 异常或超时,检查自愈配置...", ex);
            // 核心自愈机制:如果开启了 fallback,则直接退化为向量检索打分排序,保障链路可用性
            if (properties.isFallbackToEmbeddingResultsOnRerankFailure()) {
                int limit = Math.min(properties.getTopN(), candidates.size());
                return candidates.stream()
                        .sorted(Comparator.comparingDouble(RetrievedChunk::embeddingScore).reversed())
                        .limit(limit)
                        .toList();
            }
            throw ex; // 若未配置则向上传递异常
        }
    }
}